package com.feature50.makeover.chart;

import com.feature50.makeover.utils.FontResolver;
import com.feature50.makeover.utils.Platform;
import com.feature50.makeover.utils.StringUtils;
import org.jdesktop.animation.timing.Animator;
import org.jdesktop.animation.timing.TimingTargetAdapter;
import org.jfree.data.category.CategoryDataset;

import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.*;

/**
 * The piece of a {@link BarChart} that draws the "bars". For an understanding of "category" and "rows" and other
 * terms related to the data structure of the bar chart, see the JFreeChart documentation.
 */
public class BarChartBars extends JComponent {
    public enum ValueAxisPosition { Left, Center, Auto }
    public enum SortMode { ColumnName, RowValue }
    public enum SelectionType { ArrowRight, Normal }

    private Comparable selectedCol;

    // the background color for every other row
    private static final Color ROW_BACKGROUND = new Color(255, 255, 255, 24);

    // the width allocated to category labels
    private int labelWidth = -1;
    private int labelWidthCheck = -1;   // the width of this component when the labelWidth was calculated

    private int valueX = -1;
    private int valueWidth = -1;
    private int valueAxis = -1;

    private int steps = 4;
    private boolean substeps = true;

    private SelectionType selectionType = SelectionType.Normal;

    private Font font;
    private CategoryDataset model;
    private Map<Object, RowMetadata> metadata = null;
    private ValueAxisPosition vap = ValueAxisPosition.Center;

    // contains the order in which the column keys in the dataset will be displayed according to the current sort; if
    // null, the natural order will be used
    private java.util.List<Comparable> columnKeysSorted = null;

    // sorts the column keys in the underlying dataset
    private Comparator comparator = null;

    // indicates how the chart is to be sorted, either by columns or row values
    private SortMode sortMode = SortMode.ColumnName;

    // indicates which row is to be used when sorting by row
    private int sortRow = 0;

    // contains the pre-sorted values for metadata; used to calculate intermediate positions while changing the sort
    private Map<Object, RowMetadata> originalMetadata;

    private Animator sortAnimator;

    // the current state of the sort effect, from 0 to 1
    private float sortBuilt = 1f;

    private float built = 0f;

    private java.util.List<BarChartSelectionListener> listeners = new ArrayList<BarChartSelectionListener>();

    private ColorResolver colorResolver = new DefaultColorResolver(new Color(190, 190, 218));

    public BarChartBars() {
        init();
    }

    public BarChartBars(CategoryDataset model) {
        this.model = model;
        init();
    }

    public BarChartBars(CategoryDataset model, ValueAxisPosition valueAxisPosition) {
        this.model = model;
        this.vap = valueAxisPosition;
        init();
    }

    public void addSelectionListener(BarChartSelectionListener listener) {
        listeners.add(listener);
    }

    public void removeSelectionListener(BarChartSelectionListener listener) {
        listeners.remove(listener);
    }

    private void fireSelectionChanged() {
        for (int i = 0; i < listeners.size(); i++) {
            BarChartSelectionListener listener = listeners.get(i);
            listener.selectionChanged(selectedCol);
        }
    }

    public SelectionType getSelectionType() {
        return selectionType;
    }

    public void setSelectionType(SelectionType selectionType) {
        this.selectionType = selectionType;
    }

    public Comparable getObjectForPoint(int x, int y) {
        if ((x < getInsets().left) || (x > getWidth() - getInsets().right)) return null;

        int cy = getInsets().top;
        if (y < cy) return null;

        for (int i = 0; i < model.getColumnCount(); i++) {
            cy += getRowHeight();
            if (y < cy) {
                return getColumnKey(i);
            }
        }

        return null;
    }

    private void init() {
        setOpaque(false);
        font = FontResolver.getFont(new FontResolver.FontInfo("LucidaGrandeBold", 8, true, true));
        setFont(font);
        setBorder(BorderFactory.createEmptyBorder(0, 1, 0, 10));

        addMouseListener(new MouseAdapter() {
            public void mouseClicked(MouseEvent e) {
                int x = e.getPoint().x;
                int y = e.getPoint().y;

                selectedCol = (Comparable) getObjectForPoint(x, y);
                repaint();

                fireSelectionChanged();
            }
        });
    }

    public CategoryDataset getModel() {
        return model;
    }

    public void setModel(CategoryDataset model) {
        this.model = model;
    }

    public Dimension getMinimumSize() {
        return getPreferredSize();
    }

    public Dimension getPreferredSize() {
        return new Dimension(1, getRowHeight() * model.getColumnCount());
    }

    public Dimension getMaximumSize() {
        return getPreferredSize();
    }

    public int getSteps() {
        return steps;
    }

    public void setSteps(int steps) {
        this.steps = steps;
    }

    public boolean hasSubsteps() {
        return substeps;
    }

    public void setSubsteps(boolean substeps) {
        this.substeps = substeps;
    }

    protected void paintComponent(Graphics g) {
        if (model == null) return;
        if (metadata == null) generateRowMetadata();

        Graphics2D g2d = (Graphics2D) g.create();

        int x, y, width, height;

        height = getRowHeight() * model.getColumnCount();

        // draw the background bars
        x = getInsets().left;
        width = getWidth() - getInsets().left - getInsets().right;
        g2d.setColor(ROW_BACKGROUND);
        for (int i = 0; i < model.getColumnCount(); i++) {
            drawRowBackground(g2d, model.getColumnKey(i), x, width);
        }

        if ((labelWidth == -1) || (labelWidthCheck != getWidth())) {
            calculateLabelWidth(g2d);
            calculateValueAxis();
        }

        // draw the grid lines
        g2d.setColor(new Color(52, 52, 52));

        int labelLineX = x + labelWidth;
        g2d.drawLine(labelLineX, 0, labelLineX, height);

        g2d.drawLine(x + labelWidth + valueWidth, 0, x + labelWidth + valueWidth, height);

        int increments = getTotalBarWidth() / getSteps();
        for (int i = 1; i < getSteps(); i++) {
            int cx = x + labelWidth + (increments * i);
            g2d.drawLine(cx, 0, cx, height);

            if (vap == ValueAxisPosition.Center) {
                cx = x + labelWidth + valueWidth - (increments * i);
                g2d.drawLine(cx, 0, cx, height);
            }
        }

        if (hasSubsteps()) {
            int offset = increments / 2;
            Stroke stroke = g2d.getStroke();
            g2d.setStroke(new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND, 10, new float[] { 1f, 2f }, 0));
            for (int i = 0; i < getSteps(); i++) {
                int cx = x + labelWidth + offset + (increments * i);
                g2d.drawLine(cx, 0, cx, height);

                if (vap == ValueAxisPosition.Center) {
                    cx = x + labelWidth + valueWidth - offset - (increments * i);
                    g2d.drawLine(cx, 0, cx, height);
                }
            }
            g2d.setStroke(stroke);
        }

        // draw the value bars
        for (int i = 0; i < model.getColumnCount(); i++) {
            Comparable col = model.getColumnKey(i);

            if (col == selectedCol) {
                drawSelectedRow(g2d, col, x, width);
            }

            drawRow(g2d, col, x, width);
        }

        // the axis line, drawn last to be on top of everything
        g2d.setColor(Color.WHITE);
        g2d.drawLine(valueAxis, 0, valueAxis, height);

        g2d.dispose();
    }

    private void drawSelectedRow(Graphics2D g2d, Comparable col, int x, int width) {
        RowMetadata m = metadata.get(col);

        int y = getCurrentY(col, m);

        int endspace = 0;
        int pointWidth = 5;

        g2d = (Graphics2D) g2d.create();
        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        for (int z = 0; z < 3; z++) {
            if (z == 0) {
                g2d.setColor(new Color(0, 0, 0, 64));
                g2d.translate(2, 2);
            } else if (z == 1) {
                g2d.setColor(new Color(0, 0, 0, 128));
                g2d.translate(-1, -1);
            } else {
                g2d.setColor(new Color(202, 202, 202));
                g2d.translate(-1, -1);
            }
            g2d.drawLine(x - 1, y + 1, x - 1, y + getRowHeight() - 2);
            g2d.fillRect(x, y, width - endspace, getRowHeight());

            if (selectionType == SelectionType.ArrowRight) {
                g2d.fillPolygon(new int[] { x + width - endspace, x + width - endspace + pointWidth + 2, x + width - endspace }, new int[] { y, y + (getRowHeight() / 2), y + getRowHeight() }, 3);
            } else {
                g2d.drawLine(x + width, y + 1, x + width, y + getRowHeight() - 2);
            }
        }
        g2d.dispose();
    }

    private void drawRowBackground(Graphics2D g2d, Comparable col, int x, int width) {
        RowMetadata m = metadata.get(col);
        if (m.isBackground()) g2d.fillRect(x, m.getY(), width, getRowHeight());
    }

    int getTotalBarWidth() {
        int width = valueWidth - (valueAxis - getInsets().left - labelWidth);
        if (built < 1f) width *= built;
        return width;
    }

    private int getCurrentY(Comparable columnKey, RowMetadata m) {
        // you can optionally pass in the RowMetadata if you already have it to spare a hashtable lookup
        if (m == null) m = metadata.get(columnKey);

        if (sortBuilt == 1f) {
            return m.getY();
        } else {
            int newY = m.getY();
            int originalY = originalMetadata.get(columnKey).getY();
            int diff = newY - originalY;
            int amt = (int) (diff * sortBuilt);
            return originalY + amt;
        }
    }

    private void drawRow(Graphics2D g2d, Comparable col, int x, int width) {
        RowMetadata m = metadata.get(col);

        x += getLabelMargin();

        Platform.setAntialiasing(g2d, font);

        g2d.setColor(getLabelColor(col));

        String label = m.getLabel();

        //float height = g2d.getFontMetrics().getLineMetrics(label, g2d).getHeight();
        int height = g2d.getFontMetrics().getAscent();

        int y = getCurrentY(col, m);
        int cy = y + ((getRowHeight() - height) / 2) + height;
        g2d.drawString(label, x, cy);

        int barCount = getBarCount();
        int barHeight = getBarHeight();
        cy = y + ((getRowHeight() - (barHeight * barCount)) / 2);

        for (int i = 0; i < barCount; i++) {
            Number number = model.getValue(model.getRowKey(i), col);
            if (number == null) continue;

            float value = number.floatValue();
            if ((vap == ValueAxisPosition.Left) && (value <= 0)) return;

            Color color = colorResolver.getColor(model.getRowKey(i), col, i, value);

            int barWidth = (int) (Math.abs(value) * getTotalBarWidth());
            if (value < 0) {
                drawBar(g2d, color, valueAxis - barWidth, cy, barWidth, barHeight);
            } else {
                drawBar(g2d, color, valueAxis + 1, cy, barWidth, barHeight);
            }

            cy += barHeight;
        }
    }

    private int getBarCount() {
        return model.getRowCount();
    }

    private void drawBar(Graphics2D g2d, Color color, int x, int y, int width, int height) {
        if (built == 0) return;

        if (height < 3) {
            g2d.setColor(color);
            g2d.fillRect(x, y, width, height);
        } else {
            if (built == 1f) {
                g2d.translate(2, 2);
                g2d.setColor(new Color(0, 0, 0, 32));
                g2d.fillRect(x, y, width, height);
                g2d.translate(-1, -1);
                g2d.setColor(new Color(0, 0, 0, 64));
                g2d.fillRect(x, y, width, height);
                g2d.translate(-1, -1);
            }

            g2d.setPaint(new GradientPaint(x, y + height, color, x, y, color.darker().darker()));
            g2d.fillRect(x, y + 1, width, height - 1);

            if (built == 1f) {
                g2d.setColor(new Color(0, 0, 0, 64));
                g2d.drawLine(x, y + 1, x, y + height - 1);
                g2d.drawLine(x + 1, y + height - 1, x + width - 2, y + height - 1);
                g2d.drawLine(x + width - 1, y + 1, x + width - 1, y + height - 1);
                g2d.setColor(new Color(color.getRed(), color.getGreen(), color.getBlue(), 64));
                g2d.drawLine(x, y, x + width - 1, y);
            }

            g2d.setColor(new Color(255, 255, 255, 192));
            g2d.drawLine(x + 2, y + 2, x + 2 + (width - 5), y + 2);
        }
    }

    private int getBarHeight() {
        return 12;
    }

    private void calculateValueAxis() {
        valueX = labelWidth;
        valueWidth = getWidth() - getInsets().left - getInsets().right - valueX;

        switch (vap) {
            case Left:
                valueAxis = valueX;
                break;
            case Center:
                valueAxis = (valueWidth / 2) + valueX;
                break;
            default:
                throw new IllegalStateException("ValueAxisPosition value not supported");
        }
    }

    private void calculateLabelWidth(Graphics2D g2d) {
        labelWidthCheck = getWidth();

        int maxWidth = getMaxWidth();
        int width = 0;

        for (int i = 0; i < model.getColumnCount(); i++) {
            Comparable col = model.getColumnKey(i);
            RowMetadata m = metadata.get(col);
            String label = getColumnLabel(col);
            if (StringUtils.nullOrEmpty(label)) continue;

            int sw = (int) g2d.getFontMetrics().getStringBounds(label, g2d).getWidth();
            if (sw > maxWidth) {
                while (sw > maxWidth) {
                    if (label.length() < 2) {
                        label = "";
                        break;
                    }
                    label = label.substring(0, label.length() - 1);
                    sw = (int) g2d.getFontMetrics().getStringBounds(label + "...", g2d).getWidth();
                }
                m.setLabel(label + "...");
            } else {
                m.setLabel(label);
            }

            width = Math.max(width, sw);
        }

        // fudge factor
        width += 5;

        labelWidth = width;
    }

    private String getColumnLabel(Comparable c) {
        return c.toString().toUpperCase();
    }

    private int getMaxWidth() {
        return (int) (getWidth() * .4);
    }

    /**
     * Returns the column key for a given index in the chart; provides a level of indirection for sorting.
     *
     * @param index
     * @return
     */
    private Comparable getColumnKey(int index) {
        if (columnKeysSorted == null) {
            return model.getColumnKey(index);
        } else {
            return columnKeysSorted.get(index);
        }
    }

    private void generateRowMetadata() {
        metadata = new HashMap<Object, RowMetadata>();

        int rowHeight = getRowHeight();
        int y = 0;
        for (int i = 0; i < model.getColumnCount(); i++) {
            boolean background = i % 2 == 0;
            Comparable col = getColumnKey(i);
            metadata.put(col, new RowMetadata(y, getColumnLabel(col), background));
            y += rowHeight;
        }
    }

    public float getBuilt() {
        return built;
    }

    public void setBuilt(float built) {
        this.built = built;
    }

    public float getSortBuilt() {
        return sortBuilt;
    }

    public void setSortBuilt(float sortBuilt) {
        this.sortBuilt = sortBuilt;
    }

    public void setSort(Comparator c, SortMode sortMode, int sortRow) {
        this.comparator = c;
        this.sortMode = sortMode;
        this.sortRow = sortRow;

        redoSort();
    }

    private void redoSort() {
        if (sortAnimator != null) {
            // I don't current handle changing the sort mid-flight with any grace; wouldn't be hard to do for demos
            sortAnimator.cancel();
            sortAnimator = null;
        }

        java.util.List<Comparable> sorted = new ArrayList<Comparable>();
        sorted.addAll(model.getColumnKeys());
        Collections.sort(sorted, new Comparator<Comparable>() {
            public int compare(Comparable o1, Comparable o2) {
                if (sortMode == SortMode.ColumnName) {
                    return comparator.compare(o1, o2);
                } else {
                    Number v1 = model.getValue(model.getRowKey(sortRow), o1);
                    Number v2 = model.getValue(model.getRowKey(sortRow), o2);
                    return comparator.compare(v1, v2);
                }
            }
        });

        columnKeysSorted = sorted;
        originalMetadata = metadata;

        generateRowMetadata();

        sortAnimator = new Animator(1000, new TimingTargetAdapter() {
            public void begin() {
                sortBuilt = 0;
            }

            public void end() {
                sortBuilt = 1f;
                repaint();
                sortAnimator = null;
            }

            public void timingEvent(float v) {
                sortBuilt = v;
                repaint();
            }
        });
        sortAnimator.setAcceleration(0.2f);
        sortAnimator.setDeceleration(0.4f);
        sortAnimator.start();
    }

    private int getRowHeight() {
        int rows = (model == null) ? 1 : model.getRowCount();
        return (getBarHeight() * rows) + 8;
    }

    private int getLabelMargin() {
        return 2;
    }

    int getValueAxis() {
        return valueAxis;
    }

    int getValueX() {
        return valueX;
    }

    int getValueWidth() {
        return valueWidth;
    }

    private Color getLabelColor(Object row) {
        if (row == null) return Color.WHITE;
        if (row == selectedCol) return Color.BLACK;
        return Color.WHITE;
    }

    public ColorResolver getColorResolver() {
        return colorResolver;
    }

    public void setColorResolver(ColorResolver colorResolver) {
        this.colorResolver = colorResolver;
    }

    public ValueAxisPosition getValueAxisPosition() {
        return vap;
    }

    private class RowMetadata {
        private int y;
        private String label;
        private boolean background;

        public RowMetadata(int y, String label, boolean background) {
            this.label = label;
            this.y = y;
            this.background = background;
        }

        public String getLabel() {
            return label;
        }

        public void setLabel(String label) {
            this.label = label;
        }

        public int getY() {
            return y;
        }

        public void setY(int y) {
            this.y = y;
        }

        public boolean isBackground() {
            return background;
        }

        public void setBackground(boolean background) {
            this.background = background;
        }
    }
}
